#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import re
import enum
import json
import shlex
import asyncio
import logging
import statistics
from datetime import datetime
from croniter import croniter
from mc_util import McUtil
from mc_util import DynObject
from mc_param import McConst


class McMirrorSiteUpdater:

    def __init__(self, param):
        self._param = param

        self._apiServer = None
        if True:
            def __clientAppear(reader, writer):
                # this function is called in asyncio task, for simplicity, no exception is allowed in this function
                peerPid = McUtil.getUnixDomainSocketPeerInfo(writer.get_extra_info('socket'))[0]
                updater = self._findUpdaterByPid(peerPid)
                if updater is None:
                    # this may happen when updater process runs before updater.proc is assigned
                    # we deny this connection, client should retry
                    logging.debug("unknown connection (pid %d)" % (peerPid))
                    writer.close()
                elif updater._sockWriter is not None or updater._sockReadTask is not None:
                    # this may happen when the updater process runs before the socket closing process for the lasy cycle finishes
                    # we deny this connection, client should retry
                    logging.debug("duplicate connection (mirror site \"%s\", pid %d)" % (updater._mirrorSite.id, peerPid))
                    writer.close()
                else:
                    updater._sockWriter = writer
                    updater._sockReadTask = McUtil.asyncSafeTaskCreate(self._param.mainloop, updater._sockReadHandler(reader), bNoCancel=True)

            async def __startServer():
                ret = await self._param.mainloop.create_unix_server(__clientAppear, McConst.updaterApiSocket)
                await ret.start_serving()
                return ret

            self._apiServer = self._param.mainloop.run_until_complete(__startServer())

        self._updaterDict = dict()          # dict<mirror-id,updater-object>

    def dispose(self):
        if hasattr(self, "_updaterDict"):
            async def __closeUpdaters(updaterDict):
                for updater in updaterDict.values():
                    # we don't need to cancel updater._taskOperation, because it is cancelled by updater._task
                    McUtil.asyncSafeTaskSetTerminateOnError(updater._task, False)
                    updater._task.cancel()
                for updater in updaterDict.values():
                    try:
                        await updater._task
                    except asyncio.CancelledError:
                        pass
            self._param.mainloop.run_until_complete(__closeUpdaters(self._updaterDict))
            del self._updaterDict

        if hasattr(self, "_apiServer"):
            if self._apiServer is not None:
                self._apiServer.close()
                # self._apiServer.abort_clients()                                           # it is added in python3.13
                self._param.mainloop.run_until_complete(self._apiServer.wait_closed())
            del self._apiServer

        del self._param

    def addMirrorSite(self, mirrorSiteId):
        assert mirrorSiteId not in self._updaterDict
        self._updaterDict[mirrorSiteId] = _OneMirrorSiteUpdater(self._param, self._param.mirrorSiteDict[mirrorSiteId])

    def removeMirrorSite(self, mirrorSiteId):
        assert mirrorSiteId in self._updaterDict
        self._updaterDict[mirrorSiteId].dispose()
        del self._updaterDict[mirrorSiteId]

    async def isMirrorSiteInitialized(self, mirrorSiteId):
        return self._updaterDict[mirrorSiteId]._updateHistory.isInitialized()

    async def getMirrorSiteUpdateStatus(self, mirrorSiteId):
        return self._updaterDict[mirrorSiteId]._status

    async def getMirrorSiteUpdateInfo(self, mirrorSiteId):
        return McMirrorSiteUpdateInfo(self._updaterDict[mirrorSiteId])

    async def cancelSleep(self, mirrorSiteId):
        if self._updaterDict[mirrorSiteId]._taskOperation is not None:
            self._updaterDict[mirrorSiteId]._taskOperation.cancel()

    def _findUpdaterByPid(self, pid):
        for u in self._updaterDict.values():
            if hasattr(u, "proc") and u.proc.pid == pid:
                return u
        return None


class McMirrorSiteUpdateStatus(enum.Enum):
    INIT = "init"
    IDLE = "idle"
    INITING = "initing"
    INIT_FAIL = "init_fail"
    UPDATING = "updating"
    UPDATE_FAIL = "update_fail"
    MAINTAINING = "maintaining"


class McMirrorSiteUpdateInfo:

    def __init__(self, mirrorSiteUpdater):
        self.status = mirrorSiteUpdater._status

        if self.status == McMirrorSiteUpdateStatus.INIT:
            pass
        elif self.status == McMirrorSiteUpdateStatus.IDLE:
            pass
        elif self.status == McMirrorSiteUpdateStatus.INITING:
            self.progress = mirrorSiteUpdater._progress
        elif self.status == McMirrorSiteUpdateStatus.INIT_FAIL:
            pass
        elif self.status == McMirrorSiteUpdateStatus.UPDATING:
            self.progress = mirrorSiteUpdater._progress
        elif self.status == McMirrorSiteUpdateStatus.UPDATE_FAIL:
            pass
        elif self.status == McMirrorSiteUpdateStatus.MAINTAINING:
            pass
        else:
            assert False


class _OneMirrorSiteUpdater:

    def __init__(self, param, mirrorSite):
        self._param = param
        self._mirrorSite = mirrorSite

        self._updateHistory = _UpdateHistory(os.path.join(self._mirrorSite.masterDir, "UPDATE_HISTORY"),
                                             self._mirrorSite.initializerExe is not None)

        if not self._updateHistory.isInitialized():
            self._status = McMirrorSiteUpdateStatus.INIT
        else:
            self._status = McMirrorSiteUpdateStatus.IDLE
            for name in self._mirrorSite.advertiserDict:
                self._param.mainloop.call_soon(lambda x=name, y=self._mirrorSite.id: False if self._param.advertiserDict[x].advertise_mirror_site(y) else False)

        self._task = McUtil.asyncSafeTaskCreate(self._param.mainloop, self._run(), taskName="mirror-site-updater-%s" % (self._mirrorSite.id), bTerminateOnError=True)
        self._taskOperation = None

    async def _run(self):
        holdFor = None

        while True:
            if self._status == McMirrorSiteUpdateStatus.INIT:
                assert holdFor is None
                holdFor = await self.__doOperation(self._init())
                continue

            if self._status == McMirrorSiteUpdateStatus.INIT_FAIL:
                assert holdFor is not None
                await self.__doOperation(asyncio.sleep(holdFor))
                holdFor = await self.__doOperation(self._init())
                continue

            if self._status == McMirrorSiteUpdateStatus.IDLE:
                assert holdFor is None
                if self._mirrorSite.updaterExe is not None:
                    curDt = datetime.now()
                    nextDt = self.__calcUpdateNextTime(curDt)
                    if nextDt > curDt:
                        await self.__doOperation(asyncio.sleep((nextDt - curDt).total_seconds()))
                    await self.__doOperation(self._update())
                elif self._mirrorSite.maintainerExe is not None:
                    await self.__doOperation(self._maintain())
                else:
                    await self.__doOperation(McUtil.asyncSleepForever())
                continue

            if self._status == McMirrorSiteUpdateStatus.UPDATE_FAIL:
                assert self._mirrorSite.updaterExe is not None
                assert holdFor is None
                curDt = datetime.now()
                nextDt = self.__calcUpdateNextTime(curDt, bUpdateFail=True)
                if nextDt > curDt:
                    await self.__doOperation(asyncio.sleep((nextDt - curDt).total_seconds()))
                await self.__doOperation(self._update())
                continue

            assert False

    async def _init(self):
        self._status = McMirrorSiteUpdateStatus.INITING
        self.__createVars()

        try:
            await self.__createProc()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" init cancelled at early stage." % (self._mirrorSite.id))
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.INIT
            raise

        logging.info("Mirror site \"%s\" init starts." % (self._mirrorSite.id))

        self._procWaitTask = McUtil.asyncSafeTaskCreate(self._param.mainloop, self._procWaitHandler(), bNoCancel=True)
        try:
            await self.__waitProcAndClient()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" init cancelled." % (self._mirrorSite.id))
            await self.__waitProcAndClient(onError=True)
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.INIT
            raise

        retCode, excInfo, holdFor = self.__getProcRetCode()
        if retCode == 0:
            self._updateHistory.initFinished(datetime.now())
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.IDLE
            logging.info("Mirror site \"%s\" init finished." % (self._mirrorSite.id))
            for name in self._mirrorSite.advertiserDict:
                self._param.mainloop.call_soon(lambda x=name, y=self._mirrorSite.id: False if self._param.advertiserDict[x].advertise_mirror_site(y) else False)
            return None
        else:
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.INIT_FAIL
            if holdFor is None:
                holdFor = _MIRROR_SITE_RESTART_INTERVAL
                msg2 = "re-initialize in %d seconds" % (holdFor)
            else:
                assert holdFor >= _MIRROR_SITE_RESTART_INTERVAL
                msg2 = "hold for %d seconds before re-initializating" % (holdFor)
            logging.error("Mirror site \"%s\" init failed (%s), %s." % (self._mirrorSite.id, self.__errStr(retCode, excInfo), msg2))
            return holdFor

    async def _update(self):
        self._status = McMirrorSiteUpdateStatus.UPDATING
        self.__createVars()

        try:
            await self.__createProc()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" update cancelled at early stage." % (self._mirrorSite.id))
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.IDLE
            raise

        logging.info("Mirror site \"%s\" update triggered on \"%s\"." % (self._mirrorSite.id, self._schedDatetime.strftime("%Y-%m-%d %H:%M")))

        self._procWaitTask = McUtil.asyncSafeTaskCreate(self._param.mainloop, self._procWaitHandler(), bNoCancel=True)
        try:
            await self.__waitProcAndClient()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" update cancelled." % (self._mirrorSite.id))
            await self.__waitProcAndClient(onError=True)
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.IDLE
            raise

        retCode, excInfo, holdFor = self.__getProcRetCode()
        self._updateHistory.updateFinished(retCode == 0, self._schedDatetime, datetime.now())
        self.__clearProcAndVars()
        if retCode == 0:
            self._status = McMirrorSiteUpdateStatus.IDLE
            logging.info("Mirror site \"%s\" update finished." % (self._mirrorSite.id))
        else:
            self._status = McMirrorSiteUpdateStatus.UPDATE_FAIL
            logging.error("Mirror site \"%s\" update failed (%s)." % (self._mirrorSite.id, self.__errStr(retCode, excInfo)))

    async def _maintain(self):
        self._status = McMirrorSiteUpdateStatus.MAINTAINING
        self.__createVars()

        try:
            await self.__createProc()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" maintain cancelled at early stage." % (self._mirrorSite.id))
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.IDLE
            raise

        logging.info("Mirror site \"%s\" maintain started." % (self._mirrorSite.id))

        self._procWaitTask = McUtil.asyncSafeTaskCreate(self._param.mainloop, self._procWaitHandler(), bNoCancel=True)
        try:
            await self.__waitProcAndClient()
        except asyncio.CancelledError:
            logging.debug("Mirror site \"%s\" maintain cancelled." % (self._mirrorSite.id))
            await self.__waitProcAndClient(onError=True)
            self.__clearProcAndVars()
            self._status = McMirrorSiteUpdateStatus.IDLE
            raise

        retCode, excInfo, holdFor = self.__getProcRetCode()
        self.__clearProcAndVars()
        self._status = McMirrorSiteUpdateStatus.IDLE
        logging.error("Mirror site \"%s\" maintain exited (%s), restart in %d seconds." % (self._mirrorSite.id, self.__errStr(retCode, excInfo), _MIRROR_SITE_RESTART_INTERVAL))

    async def _procWaitHandler(self):
        logging.debug("Mirror site \"%s\" %s process %d." % (self._mirrorSite.id, self._logName, self.proc.pid))
        try:
            await self.proc.wait()
        finally:
            logging.debug("Mirror site \"%s\" %s process terminated (returncode %d)." % (self._mirrorSite.id, self._logName, self.proc.returncode))

    async def _sockReadHandler(self, reader):
        logging.debug("Mirror site \"%s\" %s client appears." % (self._mirrorSite.id, self._logName))
        try:
            while True:
                buf = await reader.readline()
                if buf == "":
                    # EOF is received
                    break
                if not buf.endswith("\n"):
                    # encounters EOF in the middle of a message
                    raise Exception("updater socket closed in the middle of a message")

                data = None
                try:
                    data = json.loads(buf[:-1])
                except json.JSONDecodeError:
                    raise Exception("invalid message received from updater socket")

                if "message" not in data:
                    raise Exception("\"message\" field does not exist in notification")
                if "data" not in data:
                    raise Exception("\"data\" field does not exist in notification")

                if data["message"] == "progress":
                    # check update status
                    if self._status not in [McMirrorSiteUpdateStatus.INITING, McMirrorSiteUpdateStatus.UPDATING]:
                        raise Exception("invalid update status for message type \"progress\"")

                    # check message format
                    if "progress" not in data["data"]:
                        raise Exception("\"data.progress\" field does not exist in notification")
                    if not isinstance(data["data"]["progress"], int):
                        raise Exception("\"data.progress\" field does not contain an integer value")
                    if not (0 <= data["data"]["progress"] <= 100):
                        raise Exception("value of \"data.progress\" (%d) is not in range [0,100]" % (data["data"]["progress"]))

                    # check data
                    if data["data"]["progress"] < self._progress:
                        raise Exception("value of \"data.progress\" (%d) is less than current (%d)" % (data["data"]["progress"], self._progress))

                    # do work
                    if data["data"]["progress"] == self._progress:
                        logging.debug("Mirror site \"%s\" %s got duplicate progress %d%%." % (self._mirrorSite.id, self._logName, self._progress))
                    else:
                        self._progress = data["data"]["progress"]
                        logging.info("Mirror site \"%s\" %s progress %d%%." % (self._mirrorSite.id, self._logName, self._progress))
                    continue

                if data["message"] == "error":
                    # check task attribute
                    if self._excInfo is not None:
                        raise Exception("exc_info already received")
                    assert self._holdFor is None

                    # check message format
                    if "exc_info" not in data["data"]:
                        raise Exception("\"data.exc_info\" field does not exist in notification")

                    # do work
                    self._excInfo = data["data"]["exc_info"]
                    logging.debug("Mirror site \"%s\" receives exc_info {%s}." % (self._mirrorSite.id, self._excInfo))
                    continue

                if data["message"] == "error-and-hold-for":
                    # check task attribute
                    if self._excInfo is not None:
                        raise Exception("exc_info already received")
                    assert self._holdFor is None

                    # check message format
                    if "exc_info" not in data["data"]:
                        raise Exception("\"data.exc_info\" field does not exist in notification")
                    if "seconds" not in data["data"]:
                        raise Exception("\"data.seconds\" field does not exist in notification")
                    if not isinstance(data["data"]["seconds"], int):
                        raise Exception("\"data.seconds\" field does not contain an integer value")
                    if data["data"]["seconds"] <= 0:
                        raise Exception("value of \"data.seconds\" (%d) is not greater than 0" % (data["data"]["seconds"]))

                    self._excInfo = data["data"]["exc_info"]
                    self._holdFor = data["data"]["seconds"]
                    logging.debug("Mirror site \"%s\" receives exc_info and hold_for {%s}, %d." % (self._mirrorSite.id, self._excInfo, self._holdFor))
                    continue

                raise Exception("message type \"%s\" is not supported" % (data["message"]))
        finally:
            logging.debug("Mirror site \"%s\" %s client disappears." % (self._mirrorSite.id, self._logName))

    def __calcUpdateNextTime(self, curDt, bUpdateFail=False):
        if bUpdateFail:
            if self._mirrorSite.updateRetryType is None:
                # no need to retry
                pass
            elif self._mirrorSite.updateRetryType == "interval":
                return curDt + self._mirrorSite.updateRetryInterval
            elif self._mirrorSite.updateRetryType == "cronexpr":
                croniterIter = croniter(self._mirrorSite.updateRetryCronExpr, start_time=curDt, ret_type=datetime)
                while croniterIter.get_current() <= curDt:
                    croniterIter.get_next()
                return croniterIter.get_current()
            else:
                assert False

        if self._mirrorSite.schedType == "interval":
            return self._updateHistory.getLastUpdateInfo().endTime + self._mirrorSite.schedInterval
        elif self._mirrorSite.schedType == "cronexpr":
            croniterIter = croniter(self._mirrorSite.schedCronExpr, start_time=self._updateHistory.getLastUpdateInfo().endTime, ret_type=datetime)
            while croniterIter.get_current() <= curDt:
                croniterIter.get_next()
            return croniterIter.get_current()
        else:
            assert False

    async def __doOperation(self, coro):
        assert self._taskOperation is None
        self._taskOperation = McUtil.asyncSafeTaskCreate(self._param.mainloop, coro)
        try:
            return await self._taskOperation
        except asyncio.CancelledError:
            if asyncio.current_task().cancelling() == 0:
                # only self._taskOperation is cancelled
                pass
            else:
                raise
        finally:
            self._taskOperation = None

    def __createVars(self):
        self._sockWriter = None
        self._sockReadTask = None

        self._procWaitTask = None

        if self._status == McMirrorSiteUpdateStatus.INITING:
            self._logName = "init"
            self._progress = 0
        elif self._status == McMirrorSiteUpdateStatus.UPDATING:
            self._logName = "update"
            self._schedDatetime = datetime.now()
            self._progress = 0
        elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
            self._logName = "maintain"
        else:
            assert False

        self._excInfo = None
        self._holdFor = None

        os.makedirs(self._mirrorSite.logDir, exist_ok=True)
        if self._status == McMirrorSiteUpdateStatus.INITING:
            if self._mirrorSite.initializerTmpDir is not None:
                os.makedirs(self._mirrorSite.initializerTmpDir)
        elif self._status == McMirrorSiteUpdateStatus.UPDATING:
            if self._mirrorSite.updaterTmpDir is not None:
                os.makedirs(self._mirrorSite.updaterTmpDir)
        elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
            if self._mirrorSite.maintainerTmpDir is not None:
                os.makedirs(self._mirrorSite.maintainerTmpDir)
        else:
            assert False

    async def __createProc(self):
        # executable
        if self._status == McMirrorSiteUpdateStatus.INITING:
            cmd = self._mirrorSite.initializerExe
        elif self._status == McMirrorSiteUpdateStatus.UPDATING:
            cmd = self._mirrorSite.updaterExe
        elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
            cmd = self._mirrorSite.maintainerExe
        else:
            assert False

        # argument
        if True:
            args = {
                "id": self._mirrorSite.id,
                "config": self._mirrorSite.cfgDict,
                "state-directory": self._mirrorSite.pluginStateDir,
                "debug-flag": "",
                "persist": self._mirrorSite.bPersist,
                "country": self._param.mainCfg["country"],
                "location": self._param.mainCfg["location"],
            }

            if self._mirrorSite.logDir is not None:
                args["log-directory"] = self._mirrorSite.logDir

            if self._status == McMirrorSiteUpdateStatus.INITING:
                if self._mirrorSite.initializerTmpDir is not None:
                    args["temp-directory"] = self._mirrorSite.initializerTmpDir
            elif self._status == McMirrorSiteUpdateStatus.UPDATING:
                if self._mirrorSite.updaterTmpDir is not None:
                    args["temp-directory"] = self._mirrorSite.updaterTmpDir
            elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
                if self._mirrorSite.maintainerTmpDir is not None:
                    args["temp-directory"] = self._mirrorSite.maintainerTmpDir
            else:
                assert False

            for storageName in self._mirrorSite.storageDict:
                args["storage-" + storageName] = self._param.storageDict[storageName].get_param_for_updater(self._mirrorSite.id)

            if self._status == McMirrorSiteUpdateStatus.INITING:
                args["run-mode"] = "init"
            elif self._status == McMirrorSiteUpdateStatus.UPDATING:
                args["run-mode"] = "update"
                args["sched-datetime"] = datetime.strftime(self._schedDatetime, "%Y-%m-%d %H:%M")
            elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
                args["run-mode"] = "maintain"
            else:
                assert False

            cmd += " " + shlex.quote(json.dumps(args))

        # rotatelog, and use return code of the main command
        cmd += " 2>&1 | " + shlex.join(McUtil.rotateLogCmdList(self._mirrorSite.mainUpdaterLogFile, McConst.rotateLogFileSize, McConst.rotateLogFileCount))
        cmd += " ; exit ${PIPESTATUS[0]}"

        # create process
        self.proc = await asyncio.create_subprocess_shell(cmd, cwd=self._mirrorSite.pluginStateDir)

    async def __waitProcAndClient(self, onError=False):
        if not onError:
            if True:
                await asyncio.shield(self._procWaitTask)
                self._procWaitTask = None
            if self._sockReadTask is not None:
                await asyncio.shield(self._sockReadTask)
                self._sockReadTask = None
        else:
            if self._procWaitTask is not None:
                McUtil.asyncProcTerminate(self.proc)
                await self._procWaitTask
                self._procWaitTask = None
            if self._sockReadTask is not None:
                await self._sockReadTask
                self._sockReadTask = None

    def __getProcRetCode(self):
        if self.proc.returncode == 0:
            assert self._excInfo is None and self._holdFor is None
        return (self.proc.returncode, self._excInfo, self._holdFor)

    def __clearProcAndVars(self):
        if hasattr(self, "_sockWriter"):
            del self._sockWriter
        if hasattr(self, "_sockReadTask"):
            assert self._sockReadTask is None or self._sockReadTask.done()
            del self._sockReadTask

        if hasattr(self, "_procWaitTask"):
            assert self._procWaitTask is None or self._procWaitTask.done()
            del self._procWaitTask

        if self._status == McMirrorSiteUpdateStatus.INITING:
            if hasattr(self, "_logName"):
                del self._logName
            if hasattr(self, "_progress"):
                del self._progress
        elif self._status == McMirrorSiteUpdateStatus.UPDATING:
            if hasattr(self, "_logName"):
                del self._logName
            if hasattr(self, "_schedDatetime"):
                del self._schedDatetime
            if hasattr(self, "_progress"):
                del self._progress
        elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
            if hasattr(self, "_logName"):
                del self._logName
        else:
            assert False

        if hasattr(self, "_excInfo"):
            del self._excInfo
        if hasattr(self, "_holdFor"):
            del self._holdFor

        if hasattr(self, "proc"):
            assert self.proc.returncode is not None
            del self.proc

        # not using the reverse destroy sequence, must be deleted after updater process terminated
        if self._status == McMirrorSiteUpdateStatus.INITING:
            if self._mirrorSite.initializerTmpDir is not None:
                McUtil.forceDelete(self._mirrorSite.initializerTmpDir)
                self._mirrorSite.initializerTmpDir = None
        elif self._status == McMirrorSiteUpdateStatus.UPDATING:
            if self._mirrorSite.updaterTmpDir is not None:
                McUtil.forceDelete(self._mirrorSite.updaterTmpDir)
                self._mirrorSite.updaterTmpDir = None
        elif self._status == McMirrorSiteUpdateStatus.MAINTAINING:
            if self._mirrorSite.maintainerTmpDir is not None:
                McUtil.forceDelete(self._mirrorSite.maintainerTmpDir)
                self._mirrorSite.maintainerTmpDir = None
        else:
            assert False

    def __pidStr(self, pid):
        if self._sockDict[pid].mirrorSiteId is not None:
            return self._sockDict[pid].mirrorSiteId
        else:
            return "(pid %s)" % (pid)

    def __errStr(self, retCode, excInfo):
        errMsg = ", message: %s" % (excInfo) if excInfo is not None else ""
        return "(code: %d%s)" % (retCode, errMsg)


class _UpdateHistory:

    def __init__(self, updateHistoryFilename, needInitialization=True):
        self._updateFn = updateHistoryFilename
        self._needInit = needInitialization
        self._maxLen = 10

        if not self._needInit:
            McUtil.touchFile(self._updateFn)

        self._updateInfoList = []
        self._readFromFile()

        self._averageUpdateDuration = None      # unit: seconds
        self._calcAverageDuration()

    def isInitialized(self):
        return os.path.exists(self._updateFn)

    def getFirstUpdateInfo(self):
        # list order: from old to new
        return self._updateInfoList[0]

    def getLastUpdateInfo(self):
        # list order: from old to new
        return self._updateInfoList[-1]

    def getAverageUpdateDuration(self):
        return self._averageUpdateDuration

    def initFinished(self, endTime):
        assert self._needInit
        assert len(self._updateInfoList) == 0

        # add init-item
        obj = DynObject()
        obj.bSuccess = True
        obj.startTime = None
        obj.endTime = endTime
        self._updateInfoList.append(obj)

        # save to file
        self._saveToFile()

    def updateFinished(self, bSuccess, startTime, endTime):
        # remove init-item
        if len(self._updateInfoList) > 0 and self._updateInfoList[-1].startTime is None:
            assert len(self._updateInfoList) == 1
            self._updateInfoList = []

        # remove update-failed-item
        # we only store the last one update-failed-item
        if len(self._updateInfoList) > 0 and not self._updateInfoList[-1].bSuccess:
            del self._updateInfoList[-1]

        # add update-item
        obj = DynObject()
        obj.bSuccess = bSuccess
        obj.startTime = startTime
        obj.endTime = endTime
        self._updateInfoList.append(obj)

        # keep list length
        while len(self._updateInfoList) >= self._maxLen:
            self._updateInfoList.pop(0)

        # post processing
        self._calcAverageDuration()
        self._saveToFile()

    def _readFromFile(self):
        lineList = McUtil.readFile(self._updateFn, defaultContent="").split("\n")
        for i in range(0, len(lineList)):
            if lineList[i].strip() == "" or lineList[i].startswith("#"):
                continue
            try:
                m = re.fullmatch(" *(true|false) +(none|\\S+ \\S+) +(\\S+ \\S+) *", lineList[i])
                if m is None:
                    raise ValueError()
                obj = DynObject()
                # column 1
                if m.group(1) == "true":
                    obj.bSuccess = True
                elif m.group(1) == "false":
                    obj.bSuccess = False
                else:
                    raise ValueError()
                # column 2
                if m.group(2) == "none":
                    obj.startTime = None
                else:
                    obj.startTime = datetime.strptime(m.group(2), McUtil.stdTmFmt())
                # column 3
                obj.endTime = datetime.strptime(m.group(3), McUtil.stdTmFmt())
                # record data
                self._updateInfoList.append(obj)
            except ValueError:
                logging.warning("Line %d is invalid in file \"%s\"." % (i + 1, self._updateFn))
                logging.warning(lineList[i])

    def _saveToFile(self):
        with open(self._updateFn, "w") as f:
            f.write("# is-successful             start-time             end-time\n")
            for item in self._updateInfoList:
                if item.bSuccess:
                    f.write("  true                    ")
                else:
                    f.write("  false                   ")
                if item.startTime is None:
                    f.write("  none                   ")
                else:
                    f.write("  " + item.startTime.strftime(McUtil.stdTmFmt()) + "    ")
                if True:
                    f.write(item.endTime.strftime(McUtil.stdTmFmt()) + "\n")

    def _calcAverageDuration(self):
        tlist = [x for x in self._updateInfoList if x.startTime is not None and x.bSuccess]     # remove init-item and update-failed-item
        if len(tlist) > 0:
            tlist = [(x.endTime - x.startTime).total_seconds() // 60 for x in tlist]
            self._averageUpdateDuration = statistics.mean(tlist)
            self._averageUpdateDuration = min(1, int(self._averageUpdateDuration))
        else:
            self._averageUpdateDuration = 1


_MIRROR_SITE_RESTART_INTERVAL = 60
